Skip to content

feat(compass): device-orientation compass widget (#163)#170

Merged
jasoneplumb merged 3 commits intomainlinefrom
feature/163-device-compass
Apr 26, 2026
Merged

feat(compass): device-orientation compass widget (#163)#170
jasoneplumb merged 3 commits intomainlinefrom
feature/163-device-compass

Conversation

@jasoneplumb
Copy link
Copy Markdown
Owner

@jasoneplumb jasoneplumb commented Apr 26, 2026

Summary

  • Top-right compass widget that shows where the device is physically facing relative to the (always-north-up) map.
  • Most useful at low speed / stationary at a junction, where GPS course is NaN and the existing heading-cone wedge fades. Lets the user rotate themselves until the compass N points up — at that point their physical orientation matches the map.
  • iOS-13+ DeviceOrientationEvent.requestPermission() is handled inside the click handler; non-iOS browsers grant immediately. Hidden entirely when the API is unavailable.

Closes #163

Files

New:

  • src/orientation.tsextractHeading(), requestOrientationPermission(), subscribeOrientation().
  • src/compass.ts — Leaflet control with an inline SVG rose; rotation driven by a --heading-deg CSS custom property.
  • src/orientation.test.ts — 5 vitest tests for heading-extraction edge cases (iOS preference, normalisation, NaN fallback).

Modified:

  • src/types.tscompassPermission on AppState (cached so subsequent taps skip the prompt).
  • src/main.ts — mounts the control after the existing top-right cluster.
  • src/style.css.compass-rose + --active / --unavailable variants, 0.12 s rotation transition, cursor: default once active.

Behaviour

State Visual Behaviour
Initial (after page load) Muted (opacity 0.55) compass with "Tap to enable" tooltip First click triggers requestOrientationPermission()
Granted Full opacity, cursor: default, rose rotates with device Subscribed to deviceorientationabsolute (or fall back to deviceorientation)
Denied / unsupported Hidden via display: none No further prompts

Test plan

  • npm run type-check clean
  • npm run lint clean
  • npm test clean (633 tests, 5 new for extractHeading)
  • npm run size clean (97.86 kB / 100 kB; +0.75 kB for the widget)
  • Manual on a real iPhone: tap the compass, accept the prompt, rotate the device — N should track True North; rotate 90° clockwise, N should rotate 90° counter-clockwise on screen
  • Manual on Android Chrome: compass should rotate without an explicit prompt
  • Desktop without sensors: compass hidden

Notes

  • W3C alpha is anti-clockwise around the device's z-axis. We flip it to clockwise (360 - alpha) for compass-bearing semantics. iOS webkitCompassHeading is already clockwise from True North so we use it directly when present.
  • The 0.12 s CSS transition smooths jitter from the magnetometer; faster values look twitchy on cheap sensors.
  • lastDeviceHeadingDeg was considered for AppState but dropped — no module currently reads it and visual rotation goes straight to a CSS custom property. Reintroduce if a future feature needs the numeric value.

🤖 Generated with Claude Code

Adds a top-right compass widget that shows where the device is
physically facing relative to the (always-north-up) map. Most useful
when stationary or at a junction — GPS course is NaN at low speeds,
but the device compass works regardless. Helps the user align their
physical orientation with the map's north-up frame.

- src/orientation.ts: thin wrapper around DeviceOrientationEvent.
  extractHeading() prefers iOS webkitCompassHeading (true-north
  calibrated) and falls back to (360 - alpha). requestOrientation-
  Permission() handles the iOS 13+ user-gesture-only permission.
  subscribeOrientation() uses 'deviceorientationabsolute' when
  available so headings don't drift without calibration.
- src/compass.ts: Leaflet control with an SVG compass rose. First
  tap requests permission; once granted, the rose rotates by
  -heading via a --heading-deg CSS custom property. Hidden when
  DeviceOrientationEvent is unavailable (desktop).
- src/types.ts: lastDeviceHeadingDeg + compassPermission on AppState.
- src/style.css: .compass-rose styles with active / unavailable
  variants and a 0.12s rotation transition for smooth tracking.
- src/orientation.test.ts: 5 unit tests covering heading extraction
  edge cases (iOS preference, normalisation, NaN fallback).

Closes #163

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jasoneplumb jasoneplumb added enhancement New feature or request UX User experience improvements review-requested Triggers Claude code review labels Apr 26, 2026
@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Code Review — feat(compass): device-orientation compass widget

Overall this is a clean, well-scoped feature. The API compatibility handling, iOS permission flow, and CSS-custom-property rotation pattern are all solid. A few things worth addressing before merge:


Bugs / Correctness

Missing onRemove on the Leaflet control (src/compass.ts)

The orientation subscription is only cleaned up via map.on('unload', …). If the control is removed programmatically (e.g. control.remove()), the deviceorientation listener leaks. Leaflet controls have a standard lifecycle method for this:

```ts
onRemove() {
if (unsubscribe !== null) unsubscribe();
},
```

The map.on('unload') listener itself also accumulates if the control is added/removed more than once — the unload handler is never de-registered.


lastDeviceHeadingDeg stored in AppState but never read (src/types.ts)

The field is written in compass.ts but nothing in this PR reads it. If it's reserved for a future integration (e.g. the heading-cone wedge using it when GPS course is NaN), a TODO comment on the field would make the intent clear; otherwise it's dead state that adds noise to AppState.


Style / Conventions

Multi-line comment blocks (src/orientation.ts, src/compass.ts)

CLAUDE.md says: "Never write multi-paragraph docstrings or multi-line comment blocks — one short line max." Both new files have multi-line JSDoc-style blocks above every function. These should be trimmed to a single line each (or removed entirely if the function name makes intent clear).


Minor / Suggestions

deviceorientationabsolute feature-detect (src/orientation.ts:51)

```ts
const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';
```

This checks for the event-handler property (which works in Chrome/Android) rather than the event type. A slightly more portable check is 'DeviceOrientationAbsoluteEvent' in window, but the current form works in practice and the fallback to deviceorientation + webkitCompassHeading covers iOS anyway. Not a blocking concern.

No webkitCompassAccuracy guard (src/orientation.ts)

iOS exposes webkitCompassAccuracy (degrees of uncertainty; values ≥ 25 indicate poor calibration). Ignoring it is fine for a v1, but flagging it here in case you want the heading to go stale/null during poor-calibration periods rather than showing a jittery reading.

Click handler attaches before the availability check (src/compass.ts:32–54)

The DeviceOrientationEvent === undefined early-hide runs after the click listener is attached. No real-world impact since the button is hidden, but reversing the order (check availability first, attach listener only if supported) would make the guard more obviously correct.

Test coverage (src/orientation.test.ts)

The five extractHeading tests are thorough and cover the important paths. subscribeOrientation and requestOrientationPermission are side-effectful and untestable without mocks, so skipping them is appropriate.


Summary

Priority Item
🔴 Should fix Add onRemove to clean up the orientation listener
🟡 Consider Remove or justify lastDeviceHeadingDeg in AppState
🟡 Should fix Trim multi-line comment blocks per CLAUDE.md convention
🟢 Optional webkitCompassAccuracy for calibration quality

The core logic (extractHeading, permission flow, CSS rotation) is correct and well-tested. Fixing the onRemove gap and comment-block style would make this merge-ready.

- Add Leaflet onRemove() lifecycle hook to clean up the orientation
  subscription if the control is ever removed (was relying on
  map's 'unload' event which leaks if the listener is added/removed
  without a full map teardown).
- Drop lastDeviceHeadingDeg from AppState — the field was written
  but never read. The visual rotation is driven directly via the
  --heading-deg CSS custom property; if a future feature needs the
  numeric value, it can be added then.
- Trim multi-line JSDoc-style comment blocks in orientation.ts and
  compass.ts to single-line per CLAUDE.md convention.

Skipped (advisory):
- webkitCompassAccuracy gating — defer until field reports of
  jittery readings show it is needed.
- 'DeviceOrientationAbsoluteEvent' in window detection vs
  'ondeviceorientationabsolute' — current detection works in
  practice and the fallback path covers iOS regardless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jasoneplumb
Copy link
Copy Markdown
Owner Author

Cycle-2 fixes pushed:

  • onRemove lifecycle hook added; unsubscribe is now a closure variable shared between onAdd and onRemove. Removed the map.on('unload') workaround.
  • lastDeviceHeadingDeg dropped from AppState — no readers existed. Visual rotation goes straight to a CSS custom property; reintroduce the field if/when another module needs the numeric value.
  • Multi-line comments trimmed to single-line per CLAUDE.md convention in both orientation.ts and compass.ts.

Skipped:

  • webkitCompassAccuracy gating — defer until field reports show it's needed.
  • 'DeviceOrientationAbsoluteEvent' in window vs 'ondeviceorientationabsolute' — current detection works on Chrome/Android; iOS hits the fallback either way.

@jasoneplumb jasoneplumb added review-requested Triggers Claude code review and removed review-requested Triggers Claude code review labels Apr 26, 2026
@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Code Review — feat(compass): device-orientation compass widget (#163)

Overview

Clean, focused feature addition. The three-file split (orientation.ts / compass.ts / tests) follows the project's module-per-concern pattern well. Permission gating, heading extraction, and Leaflet control wiring are all nicely separated.


Issues

1. PR description / diff discrepancy — lastDeviceHeadingDeg

The PR summary says:

src/types.tslastDeviceHeadingDeg + compassPermission on AppState

But the diff only adds compassPermission. lastDeviceHeadingDeg appears nowhere in the code. This is either a stale description (field was dropped during development) or an omission. If it was intentionally dropped, update the description. If other modules are ever expected to read the current device heading through AppState, it needs to be wired in here.

2. deviceorientationabsolute detection may miss some browsers

// orientation.ts:37
const eventName = 'ondeviceorientationabsolute' in window ? 'deviceorientationabsolute' : 'deviceorientation';

Checking for the on* handler property works on Chrome/Edge but is not guaranteed on every browser — the event can fire without the ondeviceorientationabsolute IDL attribute being present. A more reliable pattern (used by e.g. compass.js) is to try the absolute event first with a one-shot listener, then fall back. That said, for the target platforms (iOS Safari / Android Chrome) the current check behaves correctly today, so this is a low-risk note rather than a blocker.

3. No throttling on orientation events

deviceorientation / deviceorientationabsolute fires up to ~60 Hz on many devices. Each event triggers a setProperty call on the DOM. The 0.12 s CSS transition smooths the visual output well, but the JS callback still runs at full sensor rate. For a single setProperty call this is negligible, but it's worth documenting or throttling if other consumers are added later.


Suggestions

4. The state.compassPermission === 'granted' guard can be tightened

// compass.ts:37
L.DomEvent.on(button, 'click', () => {
  if (state.compassPermission === 'granted') return;
  ...

This is correct, but once granted the button still receives clicks and silently swallows them. Consider either removing the listener after granting or changing the cursor to default so users aren't confused when nothing happens after tapping an already-active compass.

5. unsubscribe closure vs. control lifecycle

The unsubscribe reference lives in addCompassControl's closure but the returned Control instance is never stored:

// compass.ts:62
new Control({ position: 'topright' }).addTo(map);

onRemove (which calls unsubscribe()) is therefore only reachable if the Leaflet map itself tears down the control. For this app's single-page lifetime that's fine, but it's worth a comment in case the control is ever conditionally mounted/unmounted.


Positives

  • extractHeading logic is correct and well-justified. The ((x % 360) + 360) % 360 normalization handles negative values and overflow cleanly; the iOS-first preference with graceful W3C fallback matches real-world device behavior.
  • iOS permission gate placement is right. Calling requestOrientationPermission() from inside the click handler ensures it's user-gesture–scoped, which iOS 13+ requires.
  • Test coverage is solid for the exported pure function. Five edge-case tests cover the most common failure modes (null alpha, NaN, negative webkitCompassHeading, wraparound). The untested browser-API functions (requestOrientationPermission, subscribeOrientation) are reasonable to leave to manual testing given the mocking cost.
  • CSS is minimal and correct. Rotating the inner svg rather than the button keeps the tap target stable, and display: none on --unavailable matches the documented "hidden entirely" behaviour.
  • void before the floating promise in the click handler is the right idiom here.
  • Size budget respected (97.86 kB / 100 kB, +0.75 kB).

Summary

One description inconsistency to resolve (lastDeviceHeadingDeg), one minor UX nit (click guard after grant), and the deviceorientationabsolute detection caveat to keep in mind. Nothing blocking. The core logic is sound.

After permission is granted the click handler silently no-ops. The
default cursor signals to the user that the compass is now passive
and not waiting for further input.

Per PR #170 cycle-2 review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented Apr 26, 2026

Code Review — feat(compass): device-orientation compass widget (#163)

Overview

Clean, well-scoped feature. The separation into orientation.ts (sensor logic) and compass.ts (Leaflet control / UI) is a good split. The iOS permission gate, deviceorientationabsolutedeviceorientation fallback, and extractHeading normalisation are all handled correctly. No security or major performance concerns.


Bug — double-tap race condition in compass.ts

src/compass.ts lines 35–48

On non-iOS browsers requestOrientationPermission() resolves synchronously (next microtask). If the user taps the button twice before the first .then() fires, both taps pass the state.compassPermission === 'granted' guard (state hasn't been written yet) and both call subscribeOrientation. The outer unsubscribe variable is overwritten by the second call, leaving the first deviceorientation listener permanently attached.

Fix — add an in-flight guard:

let permissionPending = false;
L.DomEvent.on(button, 'click', () => {
  if (state.compassPermission === 'granted' || permissionPending) return;
  permissionPending = true;
  void requestOrientationPermission().then((permission) => {
    permissionPending = false;
    state.compassPermission = permission;
    // … rest unchanged
  });
});

Minor issues

src/compass.ts — button remains interactive after activation

Once the compass is active the click handler returns early, but the button is still focusable and has no aria-disabled signal. A screen reader will announce "Compass, button" with no indication it can't be activated. Consider button.setAttribute('aria-disabled', 'true') alongside cursor: default when transitioning to the active state.

src/orientation.ts lines 14–17 — double-mod is noisy

return ((360 - event.alpha) % 360 + 360) % 360;

event.alpha is [0, 360) per spec, so 360 - alpha is (0, 360] and the outer + 360) % 360 only matters for the exact alpha === 0 edge case (producing 360). A simpler form with an explicit guard is easier to reason about:

return event.alpha === 0 ? 0 : 360 - event.alpha;

(The webkitCompassHeading normalisation path above it is fine — that one genuinely needs the double-mod.)


What's good

  • onRemove unsubscribes the orientation listener — no teardown leak on the happy path.
  • CSS custom property (--heading-deg) keeps heading updates off the JS layout path; 0.12s linear is the right call for magnetometer jitter.
  • extractHeading test suite covers the iOS preference, W3C flip, out-of-range normalisation, null/NaN fallback, and the mixed NaN case — solid coverage for a pure function.
  • Caching compassPermission on AppState correctly avoids re-prompting within a session.
  • Bundle impact (+0.75 kB) is trivial; size-limit still passes.

Summary

The double-tap race is worth fixing before merge; the other two points are nice-to-have. Everything else is solid — correct handling of the iOS/W3C heading-direction difference is the trickiest part of this widget and it's done right.

@jasoneplumb jasoneplumb merged commit 90eebeb into mainline Apr 26, 2026
2 checks passed
@jasoneplumb jasoneplumb deleted the feature/163-device-compass branch April 26, 2026 04:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request review-requested Triggers Claude code review UX User experience improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

consider adding a real compass to orient in the direction of the path forward

1 participant